-
Notifications
You must be signed in to change notification settings - Fork 12.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Structural tag type
brands
#33290
Structural tag type
brands
#33290
Conversation
aa08d39
to
ddf4c06
Compare
@typescript-bot pack this just cuz |
Heya @weswigham, I've started to run the tarball bundle task on this PR at ddf4c06. You can monitor the build here. It should now contribute to this PR's status checks. |
Hey @weswigham, I've packed this into an installable tgz. You can install it for testing by referencing it in your
and then running |
If one wanted the same behavior as the other PR (nominally typed, different libraries are incompatible, different versions of same library are incompatible), would it be done like this? type NormalizedPath = string & tag unique symbol;
type AbsolutePath = string & tag unique symbol; If so, this PR basically has all the features of the other PR and more. |
Not quite - declare var normalizedSym: unique symbol;
type NormalizedPathBrand = tag { [normalizedSym]: void; };
type NormalizedPath = string & NormalizedPathBrand;
declare var absoluteSym: unique symbol;
type AbsolutePathBrand = tag { [absoluteSym]: void; };
type AbsolutePath = string & AbsolutePathBrand; or with a hypothetical declare var normalizedSym: unique symbol;
type NormalizedPath = string & Tag<typeof normalizedSym>;
declare var absoluteSym: unique symbol;
type AbsolutePath = string & Tag<typeof absoluteSym>; |
I was under the impression that this would work, declare var normalizedSym: unique symbol;
type NormalizedPath = string & tag typeof normalizedSym; Since,
Is there a reason to favor the declare var normalizedSym: unique symbol;
type NormalizedPath = string & tag { [normalizedSym]: void; }; |
So after my initial knee-jerk reaction favoring
Edit: Allowing the tag properties to be typed enables phantom types to be expressed very easily; ignore the above paragraph. |
On the other hand, if the type of the tag properties is not artificially limited to So in case it wasn’t obvious: I retract my previous suggestion of dropping the types from the tag literal. 😉 |
One of the rough edges with existing branding techniques like this is that the printed type is pretty noisy once you have multiple brands assigned at the same time. For example, using id: t.Branded<t.Branded<string, NonEmptyStringBrand>, UuidBrand> When you use a lot of these sorts of types, you can end up with compound types that are difficult to read. Would this PRs approach help in this regard? The other PR intuitively seems to suggest that if I create a nominal type along the lines of type NonEmptyString = unique string;
type Uuid = unique string;
type UserId = NonEmptyString & Uuid; then I'd just see id: UserId |
They can "fix" that nesting by changing the definition of export type Branded<A, B> = [A & Brand<B>][0] This should then give you, id: string & Brand<NonEmptyStringBrand> & Brand<UuidBrand> So, the problem isn't a problem with branding in particular |
Small Q: What is the planned behaviour of the following? type GetTag<T> = T extends tag infer U ? U : never Right now I don't think it was working, but is the plan for it to infer the tag as one would expect? Is there strong motivation for having tags be in the domain of types, beyond implementation? Clearly you get a huge amount of the implementation for free, but I wonder if the user experience in the most general case is negatively impacted. As an example, it would seem natural, or at least satisfy most requirements, to write : type NormalizedPath = string & tag "NormalizedPath";
type AbsolutePath = string & tag "AbsolutePath";
type NormalizedAbsolutePath = NormalizedPath & AbsolutePath;
declare function isNormalizedPath(x: string): x is NormalizedPath;
declare function isAbsolutePath(x: string): x is AbsolutePath;
declare function consumeNormalizedAbsolutePath(x: NormalizedAbsolutePath): void;
const p = "/a/b/c";
if (isNormalizedPath(p)) {
if (isAbsolutePath(p)) {
consumeNormalizedAbsolutePath(p);
}
} however here the tag of The intended way to write this: Are there use-cases where having tags be a type is either very intuitive, or capable of expressing some clearly desirable pattern? I'm not yet convinced GADT's fallout of this nicely, but I'm more than happy to be corrected. I'm sure someone will come along with an incredibly detailed implementation of units-of-measure, but these complicated conditional types are really hard to debug and understand. So I guess my point is here: if we are assuming a blank slate to implement a new feature with new syntax, is this approach the best approach for most cases most of the time? Or as an open challenge:
I'm not really sure what is better right now, I'm just curious to understand the design space more and see how people would use it. |
I hadn't considered the
F#'s implementation is so good
Starts to write example
Ah. Nevermind. One such complicated example that would benefit from structural tags, |
Yeah, that should work (at least I don't see why it would not). Thanks for the report - I think because I have
Phantom types, mostly. Much like with our implementation of mapped types and conditional types, I think having a general underlying mechanism that enables multiple usecases, but also presents with a simple interface for common cases (like
Most certainly not possible. A key feature of units of measure is composition over algebraic operators (imo), which this does not enable. So while you could track units, you can't manipulate them easily, so... eh.
Some of the functional utility libraries I've read ( |
They are fair points, but just to debate the pointfor the sake of it: I think solving multiple use cases is only one aspect: it's probably worth evaluating it against the distribution of uses cases and how well each one is addressed. What is the magnitude of the improvement for the phantom type use case? Not having ghost members is useful, as is an optimised representation, but if you're doing anything non-trivial you'll still be doing complex type logic or relying on casts to push through generic constraints that are hard to verify. Not to mention that phantom types are never really going to be that useful unless you have the associated constraints discharged on 'pattern matching', IMO. Conversely, it seems like the majority of users really do want branding (at least from the examples in associated issues) and there is the opportunity to deliver first-class support for that feature. Though, if I'm being honest, if there was new logic added for type display such that string & Tag<"NormalizedPath" | "AbsolutePath"> rather than: string & tag ({ NormalizedPath: void } & { AbsolutePath: void }) it would probably be fine. And FWIW, I prefer this to This was the units-of-measure implementation I was thinking of: https://github.com/jscheiny/safe-units. |
@jack-williams I've done one better - not only are instances of the global Also tags now distribute over unions, because I neglected that in the first pass, and it really doesn't seem to make as much sense if they don't. This means |
5952f5c
to
b38d95f
Compare
@typescript-bot pack this again now that we do inference, unions, and aliases better |
Heya @weswigham, I've started to run the tarball bundle task on this PR at b38d95f. You can monitor the build here. It should now contribute to this PR's status checks. |
Hey @weswigham, I've packed this into an installable tgz. You can install it for testing by referencing it in your
and then running |
This looks interesting and syntactically reminds me of how the library type Key<A> = string & tag { Key: A } , is currently implemented like the following in interface Key<A> extends Newtype<
{ readonly Key: unique symbol, readonly phantom: A },
string
> {} although the interface Newtype<URI, A> {
readonly _URI: URI
readonly _A: A
} While this gives you the power to express phantom types by altering the nominal type via a structural means, I do think it sort of leaks implementation details that the user shouldn't necessarily need to care about. It's definitely better than not being able to do this at all, but I think it would be more ideal if such structural modification was done behind the scenes without the user needing to explicitly modify the structure of a tag. |
🎉 🎊 YES! I was starting to think I was the only one who saw the value of these, but they're a bit tricky to represent under the current realization of structural typing... |
@weswigham Those baselines look really nice now. Would it be possible for an interface to extend a tag type? interface Parent extends Tag<"A"> { }
interface Child1 extends Parent, Tag<"B"> { }
interface Child2 extends Parent, Tag<"C"> { } |
I tried pulling that tgz and using it locally and it failed… did I do something wrong? |
Most slashes have some kind of meaning in a shell, so |
This reverts commit 2cd6752.
@typescript-bot pack this |
Heya @weswigham, I've started to run the tarball bundle task on this PR at cc8764f. You can monitor the build here. It should now contribute to this PR's status checks. |
Hey @weswigham, I've packed this into an installable tgz. You can install it for testing by referencing it in your
and then running |
@typescript-bot pack this one last time just to check if lints and scripts do work now |
Heya @weswigham, I've started to run the tarball bundle task on this PR at cc8764f. You can monitor the build here. It should now contribute to this PR's status checks. |
Hey @weswigham, I've packed this into an installable tgz. You can install it for testing by referencing it in your
and then running |
Alright, sorry for the spam for anyone watching - I had to debug a small issue with the build, but the tarball should be functional again. |
Thanks for getting it straightened out! |
It looks like you've sent a pull request to update our 'lib' files. These files aren't meant to be edited by hand, as they consist of last-known good states of the compiler and are generated from 'src'. Unless this is necessary, consider closing the pull request and sending a separate PR to update 'src'. |
Would it be possible to support Record/index types with either of these proposals? A common pattern in redux involves creating a "normalized" store of objects, where objects are stored in maps indexed on unique identifiers. Since redux stores are POJOs, we can't simply use Here's some simple test code that currently errors on the experimental build: type UserId = string & Tag<'unique-userid'>;
const userRec: Record<UserId, string> = {};
const userMap: {
[idx: UserId]: string;
} = {};
declare const userId: UserId;
userRec[userId] = 'bob';
console.log(userRec[userId]);
userMap[userId] = 'bob';
console.log(userMap[userId]); It seems like if the |
It looks like you've sent a pull request to update our 'lib' files. These files aren't meant to be edited by hand, as they consist of last-known good states of the compiler and are generated from 'src'. Unless this is necessary, consider closing the pull request and sending a separate PR to update 'src'. |
1 similar comment
It looks like you've sent a pull request to update our 'lib' files. These files aren't meant to be edited by hand, as they consist of last-known good states of the compiler and are generated from 'src'. Unless this is necessary, consider closing the pull request and sending a separate PR to update 'src'. |
You'd just need #26797 (with either) |
Another minor difference I thought of today while debugging an issue with a manual You can try this code against the test build the bot created: // Experimental Tag Types from this PR
type Builtin = string & Tag<'builtin'>;
// true
type yy = Builtin extends string ? true : false;
// false
type zz = Builtin extends object ? true : false;
// DIY tag type solution
type RetroTag<T, K> = T & { __tag: K }
type Retro = string & RetroTag<string, 'retro'>;
// true
type aa = Retro extends string ? true : false;
// true
type bb = Retro extends object ? true : false; In my case, today I had to special case a type conditional to avoid having my tag types be treated as objects. With the new behavior exhibited by the builtin implementation here, I wouldn't have had to. So, I'd consider this an improvement over the DIY solution. But probably worth noting for anyone trying to convert from DIY to builtin. |
This experiment is pretty old, so I'm going to close it to reduce the number of open PRs. |
Would the authors consider revisiting this work? There seem to be quite a few people interested in the feature (see the recent comments on #33038), though this approach seems to be the better approach. |
This is effectively an implementation sketch for #202. We'd prefer people interact with the suggestion issues than the PRs, since the implementation of a feature is mechanical once the design has been worked out, and nominal types are still very much under discussion |
Consider this a competitor to (or at least consideration for) #33038.
This makes explicit the current patterns of structural branding and reserves their functionality with special syntax. The newly introduced syntax is the new keyword type operator
tag T
, whereT
can be any type. It is used like so:This PR also now adds a global
type Tag<T extends string> = tag {[K in T]: void};
for convenience, which means instead of the above, you can write:which gives a simple way to get a nice string-based pseudo-unique tag.
The operand type does not contribute to the visible structure of the type in any way (a
tag
still looks likeunknown
when queried), howevertag
types are related with one another based on their argument type to ensure tag compatibility.Compared with nominal tags, this has some upsides: